查看原文
其他

新技术系列:RecyclerView 的新伙伴 ConcatAdapter

琼珶和予 鸿洋 2021-10-13

本文作者


作者:琼珶和予

链接:

https://www.jianshu.com/p/67b0a3c3c2b3

本文由作者授权发布。


最近在学习了一个RecyclerView新的组件--ConcatAdapter,今天打算写一篇文章来学习一下它的源码实现。在这之前,我就学习过ConcatAdapter的用法,但是当时只是仅限于用法,并没有深入理解它的内部实现,所以不免有些遗憾。今天,我们就来学习一下。


1概述


ConcatAdapterRecyclerView是在1.2.0版本上推出的新组件,在alpha04版本之前它叫MergeAdapter,但是从alpha04版本开始,Google爸爸就将其改名为ConcatAdapter


所以,可能有些同学对此有些陌生,但是ConcatAdapter实际上就是之前的MergeAdapter,用法和实现都是差不多的。


相信大家在开发过程中都会遇到一种情况:
RecyclerView在使用Adapter加载数据的时候,可能会区分多种ViewType,一般的处理方式都是通过重新AdaptergetItemViewType方法返回不同的ViewType,然后在onCreateViewHolder方法里面通过不同的ViewType来定义不同的布局。


从一个具体的场景来说,RecyclerView通常会被分为三个部分,分别是:Header部分,Content部分,Footer部分,这其中三个部分的布局均不相同,所以就需要通过不同的ViewType来实现目的。


不过,一般项目里面会通过这种场景的逻辑均是通用的,所以我们最好是能将上面的逻辑定义成通用的,这样复用起来就非常的方便。将这部分的逻辑定义在基类
Adapter里面也是可以的,但是正所谓继承的方式没有组合的方式好,所以如果能通过组合的方式实现AdapterViewType的拆分便是极好的。


ConcatAdapter便可以将几个Adapter组合成为一个Adapter,每个子Adapter里面的ViewType是相同,子Adapter之间的ViewType可以是不同的,这样便能将不同的逻辑拆分,后续在复用起来就会更加的方便。


回到上面提到的具体场景,因为
RecyclerView分为三个部分,所以我们也可以将Adapter拆分三个部分,不同的部分使用不同的Adapter,然后通过ConcatAdapter组合起来。
  

废话说的比较多,回到今天的主题,我们今天主要是介绍ConcatAdapter的基本使用和实现原理,主要是从如下几个方面来介绍ConcatAdapter

  1. ConcatAdapter的基本使用。

  2. ConcatAdapter的基本架构。

  3. ConcatAdapter针对ViewType的处理。

  4. ConcatAdaprer针对stableId的处理。


2ConcatAdapter的基本使用


在正式分析ConcatAdapter源码之前,我们先来了解一下ConcatAdapter的基本使用。我们就按照上面的HeaderContentFooter三个部分的场景,实现一个小小的Demo,具体的效果如下图:



首先,我们要先定义三个Adapter,用来加载不同部分的布局,假设分别称为:HeaderAdapterContentAdapter FooterAdapter


这里就不详细展开具体的实现,都是比较简单的逻辑。


定义好三个
Adapter之后,接下来就是使用ConcatAdapter将三个Adapter组合起来。


组合步骤主要分为如下2步:


1、定义ConcatAdapterConfig。主要是配置ViewType是否相互隔离,以及stableId的策略。


2、使用ConcatAdapter将子Adapter组合起来。

我们来看一下具体的代码:


class MainActivity : AppCompatActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // 1. 定义Config
        val config = ConcatAdapter.Config.Builder()
            .setIsolateViewTypes(true)
            .setStableIdMode(ConcatAdapter.Config.StableIdMode.SHARED_STABLE_IDS)
            .build()
        val adapter = ConcatAdapter(config)
        // 2. 使用ConcatAdapter将三个Adapter组合起来。
        adapter.addAdapter(HeaderAdapter(generateList("Header"2)).apply { setHasStableIds(true)})
        adapter.addAdapter(ContentAdapter(generateList("Content"2)).apply { setHasStableIds(true)})
        adapter.addAdapter(FooterAdapter(generateList("Footer"2)).apply { setHasStableIds(true) })
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
    }

    private fun generateList(title: String, count: Int) = ArrayList<String>().apply {
        for (index in 0 until count) {
            add("$title position = $index")
        }
    }
}


ConcatAdapter的基本使用过程就是如上所说的内容,但是其中还有很多隐藏细节我并没有,比如说在定义Config的时候,setIsolateViewTypessetStableIdMode这两个方法的作用是什么,以及ConcatAdapter究竟是怎么将子Adapter串联起来的。这些问题的答案,我都会在后面的内容详细介绍。


3ConcatAdapter的基本构架


了解了ConcatAdappter的基本使用之后,接下来我们将正式分析ConcatAdapter的实现原理,首先我们从它的基本架构说起。


从大的方面说起,
ConcatAdappter的内部实现主要分为3层,分别如下:


1、ConcatAdapter层ConcatAdappter实现了RecyclerView#Adapter的很多方法,它主要是面对于RecyclerView


2、ConcatAdapterController层ConcatAdapterController可以认为是ConcatAdapter的代理类,它接管了ConcatAdapter的很多方法,包括核心的onCreateViewHolderonBindViewHoldergetItemCount等方法,所以各种处理逻辑并不是在ConcatAdapter中,而是在ConcatAdapterController里面进行的。


3、Helper层:在这里,ConcatAdapterController里面用到的Helper类都应该属于Helper层,其中极具代表性的是l两个类:ViewTypeStorage主要是用于处理ViewType相关的逻辑;StableIdStorage主要是用于处理stableId相关的逻辑。在这一层的类,主要的作用帮助ConcatAdapterController处理相关逻辑。

  

整个执行流程如下图:



基本结构大致都了解,接下来我们就一层一层的分析。


(1). ConcatAdapter


ConcatAdapter我们应该都非常的熟悉,其实它内部的实现跟我们平时使用的Adapter没有多大的差别,常用的方法就那么几个,可能不同的地方就在于ConcatAdapter将实现逻辑放在了ConcatAdapterController里面的。


尽管如此,我们还是要特别说明几个地方,如下:


1、在通过ConcatAdapter的构造方法构造一个对象时,我们会发现构造方法上有一个很重要的参数--Config,尽管我们可以调用不带Config的构造方法,但是实际上也会传递一个Config#DEFAULT对象。


这个Config非常的重要,里面记录了组合Adapter在处理ViewTypeStableId上采用的策略。关于这两个细节,这里先不展开,我们后续后内容专门的分析他俩。


2、ConcatAdapter不能通过setHasStableIds方法设置Adapter支持stableId


如果想要支持stableId,要分为两步:首先要在ConfigStableIdMode设置为ISOLATED_STABLE_IDS或者SHARED_STABLE_IDS;其次添加进来的子Adapter必须支持stableId


3、ConcatAdapter不能通过setStateRestorationPolicy方法设置RecyclerView恢复状态的策略。


可能有些同学不知道setStateRestorationPolicy方法是什么,我在这里科普一下。


我们都知道Activity在某些情况下会发生重建行为,重建之后需要恢复之前的状态,比如说RecyclerView要恢复到重建之前的位置,但是在这之前,是无脑的恢复,没有讨论RecyclerView数据为空的情况,所以在RecyclerView数据为空的时候,我们直接恢复之前的滑动位置是不会生效的,此时如果想要等到RecyclerView不为空时才生效,就需要setStateRestorationPolicy方法。


从1.2.0版本开始,RecyclerView增加了一个setStateRestorationPolicy方法来保证尽管数据为空状态也可以正常恢复。其中StateRestorationPolicy一共有三种模式,如下:


而在ConcatAdapter里面,StateRestorationPolicy就变得较为复杂一些,因为每个Adapter设置的模式可能会不一样,所以就需要统一下。


ConcatAdapter里面,采用的方式:


首先如果有一个Adapter设置为PREVENT,那么所有的Adapter都是PREVENT模式;


然后,如果有一个Adapter设置为PREVENT_WHEN_EMPTY,并且当前所有AdapteritemCount总和为0,那么所有的Adapter都是PREVENT_WHEN_EMPTY模式;其它的情况表示所有Adapter都是ALLOW模式。


这一块的逻辑在ConcatAdapterControllercomputeStateRestorationPolicy方法里面,这个方法在我们每次调用addAdapter方法添加一个新的Adapter或者removeAdapter方法移除一个Adapter都会被调用,有兴趣的同学可以看一下这个方法的代码。


(2). ConcatAdapterController


ConcatAdapterController相对于ConcatAdapter来说,要复杂一些,我们先来梳理大体的实现。

ConcatAdapterController内部,每个子Adapter都会被封装成为一个NestedAdapterWrapper类,所以ConcatAdapterController的所有回调方法都是直接或者间接通过调用NestedAdapterWrapper对应的方法实现逻辑。而每个方法里面所需要的NestedAdapterWrapper对象都是通过两种方式来获取的:


1、从ConcatAdapterController的缓存数组mWrappers获取:当我们调用addAdapter方法,会将每个Adapter包装成为一个NestedAdapterWrapper对象,同时会将这个对象添加到mWrappers数组里面去,可以通过position其他地方直接获取。


2、从ViewTypeStorage里面获取:ViewTypeStorage会通过不同的ViewType缓存不同的NestedAdapterWrapper,可以通过ViewType来获取。


区分一下ConcatAdapterController所有需要NestedAdapterWrapper对象的方法,只有onCreateViewHolder方法是通过方式2获取的,其他方法都是通过方式1获取的。


正因为如此差别,就会出现一个特别的现象就是,一个子AdapteronBindViewHolder方法里面带ViewHolder,不一定是自己的onCreateViewHolder方法创建,因为ConcatAdapterControlleronCreateViewHolder方法里面和onBindViewHolder方法里面使用的NestedAdapterWrapper对象不一定是同一个。


这一点大家一定要特别注意。具体是什么情况下才会出现对象不一样的问题,这个在分析ViewType的时候会重点介绍。


ConcatAdapterController除了将ConcatAdapter的回调分发到每个子Adapter里面,还有一个作用就是将每个子Adapter数据变换的通知同步到ConcatAdapter里面去,因为从类图上来看,ConcatAdapterController实现了NestedAdapterWrapper.Callback接口,每个子Adapter都会通过该接口来通知数据变化的信息。


Helper层涉及到地方比较零散,不方便分析,这里就不展开。接下来,我们将分析ConcatAdapter的核心--ViewTypestablId


4ViewType的处理策略


将多个Adapter组合到一个Adapter里面,我们需要考虑一个问题,就是如果子Adapter有可能返回相同的ViewType,面对这种情况,ConcatAdapter应该让哪个子Adapter来创建ViewHolder呢?


这是一个非常重要的问题。我们先来看一下Config针对于ViewType已有处理策略,即isolateViewTypes不同取值的含义。



我们在使用的时候,可以直接通过设置这个字段的值,以达到不同的目的。


但是有没有思考过,ConcatAdapter是怎么处理的呢?


接下来我们将正式这两种策略的实现原理,不过在这之前我们来了解一下实现ViewType处理策略整体结构。


(1).ViewTypeStorage

ConcatAdapterContrller在处理ViewType时,会根据我们isolateViewTypes不同取值创建不同ViewTypeStorage对象,我们先来看一下这个接口的结构:



我分别解释一下这两个方法,含义如下表:



大家可能会对ViewTypeLookup有疑惑,在这里,我简单的解释一下,先来看一下ViewTypeLookup的类图:



ViewTypeLookup的作用就是将localTypeglobalType相互转换。那么怎么理解这两个type呢?


我们可以这样认为:localType是每个子Adapter返回产生的,globalTypeConcatAdapter产生的。


ConcatAdapter需要将ViewType传递给子Adapter,就先要将它的globalType转换成为子Adapter能识别的localType;同时,ConcatAdapter产生的ViewType并不是它自己产生的,而是调用每个子AdaptergetItemViewType方法获取,然后然后通过localToGlobal方法转换成为globalType


这一点,我们可以从ConcatAdapterController里面找到答案:


public int getItemViewType(int globalPosition) {

   // 1. 通过position获取一个WrapperAndLocalPosition对象,这里面封装的是
   // NestedAdapterWrapper和localPosition。
    WrapperAndLocalPosition wrapperAndPos = findWrapperAndLocalPosition(globalPosition);
   // 2. 调用NestedAdapterWrapper的getItemViewType方法返回一个ItemViewType。
   // 这里返回的ViewType就是globalType,NestedAdapterWrapper的内部进行了一次localToGlobal转换。
    int itemViewType = wrapperAndPos.mWrapper.getItemViewType(wrapperAndPos.mLocalPosition);
    releaseWrapperAndLocalPosition(wrapperAndPos);
    return itemViewType;
}

如上方法分为两步,我简单的总结一下:


1、先是通过globalPosition获取一个WrapperAndLocalPosition,这里面封装的是 NestedAdapterWrapperlocalPosition


很明显,这里用到的NestedAdapterWrapper是通过上面介绍的方式1获取的。


findWrapperAndLocalPosition非常的重要,在ConcatAdapterController内部的很多地方都在调用,它的作用就是通过globalPosition找到对应的NestedAdapterWrapper,这里就不展开细讲了,有兴趣的同学可以了解一下。


2、调用NestedAdapterWrappergetItemViewType


NestedAdapterWrappergetItemViewType的内部,其实分为两步:


首先是调用子AdaptergetItemViewType方法获取localType


然后就是调用ViewTypeLookuplocalToGlobal方法将localType转换成为globalType


从整体来说,ViewTypeStorage是服务于ConcatAdapter,因此不管子Adapter有多少个,只会有一个ViewTypeStorage对象;而ViewTypeLookup是服务于子Adapter,因此有多少个子Adapter,就会创建多少个ViewTypeLookup对象。


ViewTypeLookup的创建是在NestedAdapterWrapper的构造方法里面进行的:


NestedAdapterWrapper(

        Adapter<ViewHolder> adapter,
        final Callback callback,
        ViewTypeStorage viewTypeStorage,
        StableIdStorage.StableIdLookup stableIdLookup) {
    // ······
    mViewTypeLookup = viewTypeStorage.createViewTypeWrapper(this);
   // ······
}


(2). 隔离ViewType


接下来,我将重点分析ViewType的两种策略。首先,我们来看隔离策略。从ConcatAdapterController的构造方法里面,我们可以知道,隔离策略用到的ViewTypeStorage的实现类是IsolatedViewTypeStorage


我们来看一下IsolatedViewTypeStorage的实现:


class IsolatedViewTypeStorage implements ViewTypeStorage {

    SparseArray<NestedAdapterWrapper> mGlobalTypeToWrapper = new SparseArray<>();
    int mNextViewType = 0;
    int obtainViewType(NestedAdapterWrapper wrapper) {
        int nextId = mNextViewType++;
        mGlobalTypeToWrapper.put(nextId, wrapper);
        return nextId;
    }
    @NonNull
    @Override
    public NestedAdapterWrapper getWrapperForGlobalType(int globalViewType) {
        NestedAdapterWrapper wrapper = mGlobalTypeToWrapper.get(
                globalViewType);
        if (wrapper == null) {
            throw new IllegalArgumentException("Cannot find the wrapper for global"
                    + " view type " + globalViewType);
        }
        return wrapper;
    }
    @Override
    @NonNull
    public ViewTypeLookup createViewTypeWrapper(
            @NonNull NestedAdapterWrapper wrapper) 
{
        return new WrapperViewTypeLookup(wrapper);
    }
    void removeWrapper(@NonNull NestedAdapterWrapper wrapper) {
        for (int i = mGlobalTypeToWrapper.size() - 1; i >= 0; i--) {
            NestedAdapterWrapper existingWrapper = mGlobalTypeToWrapper.valueAt(i);
            if (existingWrapper == wrapper) {
                mGlobalTypeToWrapper.removeAt(i);
            }
        }
    }
    class WrapperViewTypeLookup implements ViewTypeLookup {
        private SparseIntArray mLocalToGlobalMapping = new SparseIntArray(1);
        private SparseIntArray mGlobalToLocalMapping = new SparseIntArray(1);
        final NestedAdapterWrapper mWrapper;
        WrapperViewTypeLookup(NestedAdapterWrapper wrapper) {
            mWrapper = wrapper;
        }
        @Override
        public int localToGlobal(int localType) {
            int index = mLocalToGlobalMapping.indexOfKey(localType);
            if (index > -1) {
                return mLocalToGlobalMapping.valueAt(index);
            }
            // get a new key.
            int globalType = obtainViewType(mWrapper);
            mLocalToGlobalMapping.put(localType, globalType);
            mGlobalToLocalMapping.put(globalType, localType);
            return globalType;
        }
        @Override
        public int globalToLocal(int globalType) {
            int index = mGlobalToLocalMapping.indexOfKey(globalType);
            if (index < 0) {
                throw new IllegalStateException("requested global type " + globalType + " does"
                        + " not belong to the adapter:" + mWrapper.adapter);
            }
            return mGlobalToLocalMapping.valueAt(index);
        }
        @Override
        public void dispose() {
            removeWrapper(mWrapper);
        }
    }
}


针对于IsolatedViewTypeStorage, 我们重点分析getWrapperForGlobalType方法和createViewTypeWrapper方法。


1、getWrapperForGlobalType方法:我们可以从上面的实现可以看出来,NestedAdapterWrapper对象是从一个数组里面获取,其中keyglobalViewType。那么NestedAdapterWrapper对象是怎么放进去的呢?


我们简单寻找一下调用关系就知道:是在IsolatedViewTypeStorageobtainViewType方法放进去的,整个调用关系如下图:



总而言之,就是在getItemViewType放入进去的。这里,我们需要特别的注意,如果ViewType采用隔离策略,那么子Adapter千万不能返回相同的ViewType


因为我们从实现来看,NestedAdapterWrapper是依靠ViewType作为存储的,那么如果有两个Adapter返回相同的ViewType,会导致获取NestedAdapterWrapper不是正确的,也就是前面说的,onCreateViewHolder调用的AdapteronBindViewHolderAdapter可能不是同一个对象。这个问题在隔离策略应该严格避免,否则容易出现莫名其妙的错误。


2、createViewTypeWrapper方法:此方法的作用是用来创建ViewTypeLookup,从上面的代码中我们可以得知,与IsolatedViewTypeStorage对应的ViewTypeLookup实现类是WrapperViewTypeLookup


从前面的介绍,我们可以知道,createViewTypeWrapper方法是在NestedAdapterWrapper的构造方法里面被调用的,在创建的同时还把NestedAdapterWrapper对象传进来的,这里就为了后来localToGlobal方法里面存储NestedAdapterWrapper对象埋下了伏笔。


前文已经介绍过,ViewTypeLookup是面向子Adapter的,所以ViewTypeLookup记录的NestedAdapterWrapper对象就是跟它对应的NestedAdapterWrapper对象。


(3). 共享ViewType


说完了隔离策略的实现,我们再来看看共享策略。


从结构来说,共享策略使用的是SharedIdRangeViewTypeStorage,同时与它对应的ViewTypeLookup实现类是WrapperViewTypeLookup;


从实现上来说,共享策略在getWrapperForGlobalType方法也是通过ViewType获取NestedAdapterWrapper对象,也是在localToGlobal方面里面将记录的NestedAdapterWrapper对象存储在一个数组里面,这些跟隔离策略都是一致的。


唯一不一致的是,NestedAdapterWrapper数组采用的是SparseArray<List<NestedAdapterWrapper>>数据数据结构,也就是说,同一个ViewType可能有多个NestedAdapterWrapper对应,这也是共享策略的特色,子Adapter可以返回相同的ViewType


那么相同的ViewTypeSharedIdRangeViewTypeStorage是怎么确定该返回哪一个NestedAdapterWrapper的呢?


我们来简单的看一下getWrapperForGlobalType方法的实现:


public NestedAdapterWrapper getWrapperForGlobalType(int globalViewType) {

    List<NestedAdapterWrapper> nestedAdapterWrappers = mGlobalTypeToWrapper.get(
            globalViewType);
    if (nestedAdapterWrappers == null || nestedAdapterWrappers.isEmpty()) {
        throw new IllegalArgumentException("Cannot find the wrapper for global view"
                + " type " + globalViewType);
    }
    // just return the first one since they are shared
    return nestedAdapterWrappers.get(0);
}


看上面的实现,我们可以知道,getWrapperForGlobalType方法直接返回的是数组第一个元素。


所以,共享策略不能保证,onBindViewHolder使用的ViewHolder是自己AdapteronCreateViewHolder方法创建来的,这一点大家一定要注意。在这里,我有一个疑问,既然始终返回的是数组第一个元素,有必要用一个数组来存储吗?


我不清楚Google是怎么考虑的。


5
StableId的处理策略


Config里面还有一个配置就是StableId的模式,从官方的文档来看,我们可以知道stableId一共有三个模式,分别如下:



stableId的设计跟ViewType的设计非常的类似,都是一个Storage类和多个Lookup类。


stableId 结构中,StableIdStorage是服务于ConcatAdapter,因为只会创建一个对象;StableIdLookup服务于子Adapter,因此每个子Adapter都会创建StableIdLookup对象。


我们来简单的看一下这两个接口的定义,
uml类图如下:



两个接口的结构从类图可以看出,我针对于他们的方法特别解释一下:


1、createStableIdLookup方法:顾名思义,就是创建一个StableIdLookup对象。在ConcatAdapterController的构造方法中,首先会根据Config里面配置创建不同的StableIdStorage实现类对象;其次在创建NestedAdapterWrapper的时候,会直接调用createStableIdLookup方法创建一个StableIdLookup对象,与新添加进来的子Adapter绑定,子Adapter需要的StableIdLookup对象就是在创建的。


2、localToGlobal方法:将子Adapter转换成为ConcatAdapter需要的globalId。因为这个方法实现不同,所以就区分出来了三种策略模式。


我们大致了解了每个模式的含义,我们分别来看一下每个模式的实现。


(1). 隔离策略


在隔离策略中,StableIdStorage的实现类是IsolatedStableIdStorageStableIdLookup的实现类是IsolatedStableIdStorage


在隔离策略中,
IsolatedStableIdStorage会把将每个子Adapter抹平,因此每个子Adapter生成的stableId都会经过localToGlobal方法转换一次,因此我们直接来看localToGlobal方法:


@Override

public long localToGlobal(long localId) 
{
    Long globalId = mLocalToGlobalLookup.get(localId);
    if (globalId == null) {
        globalId = obtainId();
        mLocalToGlobalLookup.put(localId, globalId);
    }
    return globalId;
}


这个方法主要经过两步:


1、判断缓存中是否已经有stableId,如果有,直接返回;如果没有则进行第二步。


2、调用obtainId获取一个新的stableId


从这里就可以应证前面所说的,隔离策略会覆盖子Adapter生成的stableId。在隔离策略中,不同的Adapter返回相同的stableId也是没有关系的,因为不同的Adapter拥有不同的StableIdLookup对象,进而mLocalToGlobalLookup缓存也是不一样的,所以他们互不影响。


(2). 共享策略


在隔离策略中,StableIdStorage的实现类是SharedPoolStableIdStorageStableIdLookup的实现类是SameIdLookup。我们直接来看一下localToGlobal方法的实现:


@Override

public long localToGlobal(long localId) {
    return localId;
}


共享策略的实现很简单,就是将localId作为globalId。从这里,我们就可以知道为啥使用共享策略时,必须保证子Adapter不能生成不同的stableId


6
总结


到这里,本文对ConcatAdapter的介绍结束了,我在这里做一个简单的总结。


1、ConcatAdapter的架构主要分为三层,分别是ConcatAdapterConcatAdapterControllerHelper


Helper层主要是包括:ViewTypeStorage--用来处理ViewType的;StableIdStorage--用来处理stableIdNestedAdapterWrapper--里面封装了子AdapterViewTypeStorageStableIdStorage等相关类。


2、ViewTypestableId的处理都采用经典的策略者模式,主要思想是通过接口将ViewTypestabId的处理方式抽象出来,然后不同策略下,使用不同的实现类,这样的实现能保证逻辑清晰,可扩展性高。


同时,其中涉及到的ViewTypeStorageStableIdStorage是服务于ConcatAdapterViewTypeLookupStableIdLookup服务于子Adapter,这一点大家一定要谨记。


注意,本文ConcatAdapter相关源码均来自于1.2.0-alpha05版本


本文参考资料:

Android-MergeAdapter闲聊片

https://juejin.cn/post/6844904167459258381

使用 ConcatAdapter 顺序连接其他 Adapter

https://mp.weixin.qq.com/s/ppmokK3__Qx1S1cMU8y7Tg




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读


两年Android研发,大厂面试问什么?
引入Jetpack架构后,你的App会发生哪些变化?
“再见RxJava,你好Flow”,线程切换哪家强?


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存